Micron Document
πŸŽ–οΈGitΠ―Ρ€Π°πŸŽ–οΈ

Commit 27e936f1045e1fdcc9e256e4ed401980e7b8f3dc


Parents : f73deee
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-06-22T11:57:07-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-06-22T16:57:07Z

feat(ui): use modem-preset-relative SNR thresholds for signal quality (#5903)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

Changes
Diff

diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt
index da6ae71cda..6bc13935d6 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt
@@ -40,6 +40,18 @@ private val ModemPreset.bandwidth: Float
return 0f
}
+/**
+ * The SNR (in dB) at or above which a packet sent with this modem preset can still be demodulated β€” i.e. the
+ * spreading-factor-determined demodulation floor. Signal quality should be judged relative to this limit, since the
+ * same SNR means very different things per preset: -15 dB is excellent on LongSlow (SF12) yet unusable on ShortFast
+ * (SF7).
+ *
+ * Values follow the Semtech SF -> SNR floor (SF7 -7.5 dB, stepping -2.5 dB per spreading factor up to SF12 -20 dB).
+ * Unknown/unset presets fall back to [ChannelOption.DEFAULT] (LongFast).
+ */
+val ModemPreset?.snrLimit: Float
+ get() = (ChannelOption.from(this) ?: ChannelOption.DEFAULT).snrLimit
+
private fun LoRaConfig.bandwidth(regionInfo: RegionInfo?) = if (use_preset) {
modem_preset.bandwidth * if (regionInfo?.wideLora == true) 3.25f else 1f
} else {
@@ -295,22 +307,26 @@ enum class RegionInfo(
}
}
-enum class ChannelOption(val modemPreset: ModemPreset, val bandwidth: Float) {
- // Grouped by range and speed for better readability
- VERY_LONG_SLOW(ModemPreset.VERY_LONG_SLOW, 0.0625f),
- LONG_TURBO(ModemPreset.LONG_TURBO, 0.500f),
- LONG_FAST(ModemPreset.LONG_FAST, 0.250f),
- LONG_MODERATE(ModemPreset.LONG_MODERATE, 0.125f),
- LONG_SLOW(ModemPreset.LONG_SLOW, 0.125f),
- MEDIUM_FAST(ModemPreset.MEDIUM_FAST, 0.250f),
- MEDIUM_SLOW(ModemPreset.MEDIUM_SLOW, 0.250f),
- SHORT_FAST(ModemPreset.SHORT_FAST, 0.250f),
- SHORT_SLOW(ModemPreset.SHORT_SLOW, 0.250f),
- SHORT_TURBO(ModemPreset.SHORT_TURBO, 0.500f),
- LITE_FAST(ModemPreset.LITE_FAST, 0.125f),
- LITE_SLOW(ModemPreset.LITE_SLOW, 0.125f),
- NARROW_FAST(ModemPreset.NARROW_FAST, 0.0625f),
- NARROW_SLOW(ModemPreset.NARROW_SLOW, 0.0625f),
+enum class ChannelOption(val modemPreset: ModemPreset, val bandwidth: Float, val snrLimit: Float) {
+ // Grouped by range and speed for better readability.
+ // snrLimit = demodulation floor for the preset's spreading factor (see [ModemPreset.snrLimit]).
+ VERY_LONG_SLOW(ModemPreset.VERY_LONG_SLOW, 0.0625f, snrLimit = -20f), // SF12
+ LONG_TURBO(ModemPreset.LONG_TURBO, 0.500f, snrLimit = -12.5f), // SF9
+ LONG_FAST(ModemPreset.LONG_FAST, 0.250f, snrLimit = -17.5f), // SF11
+ LONG_MODERATE(ModemPreset.LONG_MODERATE, 0.125f, snrLimit = -17.5f), // SF11
+
+ // SF12: physically -20 dB. NB: Meshtastic-Apple's snrLimit() returns -7.5 here, which is the SF7 value
+ // and an apparent bug β€” see meshtastic/Meshtastic-Android#5446.
+ LONG_SLOW(ModemPreset.LONG_SLOW, 0.125f, snrLimit = -20f), // SF12
+ MEDIUM_FAST(ModemPreset.MEDIUM_FAST, 0.250f, snrLimit = -12.5f), // SF9
+ MEDIUM_SLOW(ModemPreset.MEDIUM_SLOW, 0.250f, snrLimit = -15f), // SF10
+ SHORT_FAST(ModemPreset.SHORT_FAST, 0.250f, snrLimit = -7.5f), // SF7
+ SHORT_SLOW(ModemPreset.SHORT_SLOW, 0.250f, snrLimit = -10f), // SF8
+ SHORT_TURBO(ModemPreset.SHORT_TURBO, 0.500f, snrLimit = -7.5f), // SF7
+ LITE_FAST(ModemPreset.LITE_FAST, 0.125f, snrLimit = -12.5f),
+ LITE_SLOW(ModemPreset.LITE_SLOW, 0.125f, snrLimit = -15f),
+ NARROW_FAST(ModemPreset.NARROW_FAST, 0.0625f, snrLimit = -10f),
+ NARROW_SLOW(ModemPreset.NARROW_SLOW, 0.0625f, snrLimit = -12.5f),
;
companion object {

diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt
index 9e2c696148..5d672477c7 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt
@@ -33,6 +33,7 @@ import org.meshtastic.core.resources.a11y_node_signal
import org.meshtastic.core.resources.now
import org.meshtastic.core.resources.unknown
import org.meshtastic.core.ui.util.formatAgo
+import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
private const val MILLIS_PER_SECOND = 1000L
private const val MAX_BATTERY_PERCENT = 100
@@ -86,6 +87,7 @@ internal fun buildNodeDescription(
viaMqtt: Boolean,
strings: NodeDescriptionStrings,
lastHeardIsRelative: Boolean = true,
+ modemPreset: ModemPreset? = null,
): String = buildString {
append(name)
append(", ")
@@ -121,7 +123,7 @@ internal fun buildNodeDescription(
append(strings.distanceAway.replace("%s", it))
}
if (hopsAway == 0 && !viaMqtt && snr < SNR_UNSET_THRESHOLD && rssi < 0) {
- val quality = determineSignalQuality(snr, rssi)
+ val quality = determineSignalQuality(snr, modemPreset)
append(", ")
append(strings.signal.replace("%s", quality.name.lowercase()))
}

diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt
index f02c17f71b..fedd89a607 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt
@@ -42,6 +42,7 @@ import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
import org.meshtastic.core.common.util.MetricFormatter
+import org.meshtastic.core.model.snrLimit
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bad
import org.meshtastic.core.resources.fair
@@ -59,13 +60,22 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
+import org.meshtastic.core.ui.util.LocalModemPreset
+import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
+// Fixed-threshold SNR colors retained for contexts without an active preset (e.g. traceroute hop coloring in
+// AnnotatedStrings). Per-node signal quality uses preset-relative thresholds instead β€” see [determineSignalQuality].
const val SNR_GOOD_THRESHOLD = -7f
const val SNR_FAIR_THRESHOLD = -15f
const val RSSI_GOOD_THRESHOLD = -115
const val RSSI_FAIR_THRESHOLD = -126
+// SNR offsets (dB) below a preset's demodulation floor that delimit the quality bands, matching Meshtastic-Apple's
+// getSnrColor(): within 5.5 dB below the limit is FAIR, within 7.5 dB is BAD, further down is NONE.
+private const val SNR_FAIR_OFFSET = 5.5f
+private const val SNR_BAD_OFFSET = 7.5f
+
@Stable
enum class Quality(
@Stable val nameRes: StringResource,
@@ -84,14 +94,19 @@ enum class Quality(
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
-fun NodeSignalQuality(snr: Float, rssi: Int, modifier: Modifier = Modifier) {
- val quality = determineSignalQuality(snr, rssi)
+fun NodeSignalQuality(
+ snr: Float,
+ rssi: Int,
+ modifier: Modifier = Modifier,
+ modemPreset: ModemPreset? = LocalModemPreset.current,
+) {
+ val quality = determineSignalQuality(snr, modemPreset)
FlowRow(
modifier = modifier,
itemVerticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
- Snr(snr)
+ Snr(snr, modemPreset = modemPreset)
Rssi(rssi)
Text(
text = "${stringResource(Res.string.signal)} ${stringResource(quality.nameRes)}",
@@ -111,21 +126,26 @@ private const val SIZE_ICON_DP = 16
/** Displays the `snr` and `rssi` with color depending on the values respectively. */
@Composable
-fun SnrAndRssi(snr: Float, rssi: Int) {
+fun SnrAndRssi(snr: Float, rssi: Int, modemPreset: ModemPreset? = LocalModemPreset.current) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
- Snr(snr)
+ Snr(snr, modemPreset = modemPreset)
Rssi(rssi)
}
}
/** Displays a human readable description and icon representing the signal quality. */
@Composable
-fun LoraSignalIndicator(snr: Float, rssi: Int, contentColor: Color = MaterialTheme.colorScheme.onSurface) {
- val quality = determineSignalQuality(snr, rssi)
+fun LoraSignalIndicator(
+ snr: Float,
+ modifier: Modifier = Modifier,
+ modemPreset: ModemPreset? = LocalModemPreset.current,
+ contentColor: Color = MaterialTheme.colorScheme.onSurface,
+) {
+ val quality = determineSignalQuality(snr, modemPreset)
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
- modifier = Modifier.fillMaxSize().padding(8.dp),
+ modifier = modifier.fillMaxSize().padding(8.dp),
) {
Icon(
modifier = Modifier.size(SIZE_ICON_DP.dp),
@@ -142,15 +162,8 @@ fun LoraSignalIndicator(snr: Float, rssi: Int, contentColor: Color = MaterialThe
}
@Composable
-fun Snr(snr: Float, modifier: Modifier = Modifier) {
- val color: Color =
- if (snr > SNR_GOOD_THRESHOLD) {
- Quality.GOOD.color.invoke()
- } else if (snr > SNR_FAIR_THRESHOLD) {
- Quality.FAIR.color.invoke()
- } else {
- Quality.BAD.color.invoke()
- }
+fun Snr(snr: Float, modifier: Modifier = Modifier, modemPreset: ModemPreset? = LocalModemPreset.current) {
+ val color: Color = determineSignalQuality(snr, modemPreset).color.invoke()
Text(
modifier = modifier,
@@ -178,10 +191,22 @@ fun Rssi(rssi: Int, modifier: Modifier = Modifier) {
)
}
-fun determineSignalQuality(snr: Float, rssi: Int): Quality = when {
- snr > SNR_GOOD_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> Quality.GOOD
- snr > SNR_GOOD_THRESHOLD && rssi > RSSI_FAIR_THRESHOLD -> Quality.FAIR
- snr > SNR_FAIR_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> Quality.FAIR
- snr <= SNR_FAIR_THRESHOLD && rssi <= RSSI_FAIR_THRESHOLD -> Quality.NONE
- else -> Quality.BAD
+/**
+ * Rates link quality from SNR relative to the active modem preset's demodulation floor ([ModemPreset.snrLimit]). A
+ * given SNR means different things per preset β€” e.g. -15 dB is excellent on LongSlow (SF12) but unusable on ShortFast
+ * (SF7) β€” so a fixed threshold mis-rates most presets.
+ *
+ * RSSI is intentionally not considered: without the noise floor it cannot indicate whether a signal is demodulable, so
+ * SNR-versus-preset-limit is the meaningful measure (it is still shown to the user via [Rssi]). See #5446.
+ *
+ * A null/unknown [modemPreset] falls back to the LongFast default limit.
+ */
+fun determineSignalQuality(snr: Float, modemPreset: ModemPreset?): Quality {
+ val limit = modemPreset.snrLimit
+ return when {
+ snr > limit -> Quality.GOOD
+ snr > limit - SNR_FAIR_OFFSET -> Quality.FAIR
+ snr >= limit - SNR_BAD_OFFSET -> Quality.BAD
+ else -> Quality.NONE
+ }
}

diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt
index 9f6077e2d6..7a99b8dd02 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt
@@ -17,11 +17,20 @@
package org.meshtastic.core.ui.component
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import org.koin.compose.koinInject
import org.meshtastic.core.navigation.MultiBackstack
import org.meshtastic.core.navigation.NodeDetailRoute
import org.meshtastic.core.navigation.NodesRoute
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.ui.util.LocalModemPreset
import org.meshtastic.core.ui.viewmodel.UIViewModel
/**
@@ -53,5 +62,16 @@ fun MeshtasticAppShell(
},
)
- MeshtasticSnackbarProvider(snackbarManager = uiViewModel.snackbarManager, hostModifier = hostModifier) { content() }
+ // Connected device's modem preset, provided once here so signal-quality rating is preset-relative across all
+ // screens without per-screen plumbing. Distinct so the value only changes when the preset itself does.
+ val radioConfigRepository = koinInject<RadioConfigRepository>()
+ val modemPreset by
+ remember(radioConfigRepository) {
+ radioConfigRepository.localConfigFlow.map { it.lora?.modem_preset }.distinctUntilChanged()
+ }
+ .collectAsStateWithLifecycle(initialValue = null)
+
+ MeshtasticSnackbarProvider(snackbarManager = uiViewModel.snackbarManager, hostModifier = hostModifier) {
+ CompositionLocalProvider(LocalModemPreset provides modemPreset) { content() }
+ }
}

diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt
index 4e4e160208..aa2542365e 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt
@@ -75,6 +75,7 @@ import org.meshtastic.core.ui.icon.MapCompass
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Notes
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
+import org.meshtastic.core.ui.util.LocalModemPreset
import org.meshtastic.proto.Config
private const val ACTIVE_BORDER_ALPHA = 0.65f
@@ -140,8 +141,9 @@ fun NodeItem(
}
val a11yStrings = rememberNodeDescriptionStrings()
+ val modemPreset = LocalModemPreset.current
val nodeDescription =
- remember(thatNode, a11yStrings) {
+ remember(thatNode, a11yStrings, modemPreset) {
buildNodeDescription(
name = originalLongName,
isOnline = thatNode.isOnline,
@@ -155,6 +157,7 @@ fun NodeItem(
rssi = thatNode.rssi,
viaMqtt = thatNode.viaMqtt,
strings = a11yStrings,
+ modemPreset = modemPreset,
)
}
@@ -317,7 +320,7 @@ private fun NodeSignalRow(thatNode: Node, isThisNode: Boolean, contentColor: Col
if (thatNode.snr < 100f) add { Snr(thatNode.snr) }
if (thatNode.rssi < 0) add { Rssi(thatNode.rssi) }
if (thatNode.snr < 100f && thatNode.rssi < 0) {
- val quality = determineSignalQuality(thatNode.snr, thatNode.rssi)
+ val quality = determineSignalQuality(thatNode.snr, LocalModemPreset.current)
add {
IconInfo(
icon = vectorResource(quality.icon),

diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt
index 85353bc199..229bc6b866 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt
@@ -89,6 +89,7 @@ import org.meshtastic.core.ui.icon.Unmessageable
import org.meshtastic.core.ui.icon.role
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
+import org.meshtastic.core.ui.util.LocalModemPreset
import org.meshtastic.proto.Config
private const val ACTIVE_BORDER_ALPHA = 0.65f
@@ -152,8 +153,9 @@ fun NodeItemCompact(
val style = if (thatNode.isUnknownUser) FontStyle.Italic else FontStyle.Normal
val a11yStrings = rememberNodeDescriptionStrings()
+ val modemPreset = LocalModemPreset.current
val nodeDescription =
- remember(thatNode, lastHeardIsRelative, a11yStrings) {
+ remember(thatNode, lastHeardIsRelative, a11yStrings, modemPreset) {
buildNodeDescription(
name = longName,
isOnline = thatNode.isOnline,
@@ -168,6 +170,7 @@ fun NodeItemCompact(
viaMqtt = thatNode.viaMqtt,
strings = a11yStrings,
lastHeardIsRelative = lastHeardIsRelative,
+ modemPreset = modemPreset,
)
}
@@ -371,7 +374,7 @@ private fun CompactHealthRow(
// Signal quality
val hasDirectSignal = thatNode.hopsAway == 0 && thatNode.snr < 100f && !thatNode.viaMqtt && thatNode.rssi < 0
if (showSignal && hasDirectSignal) {
- val quality = determineSignalQuality(thatNode.snr, thatNode.rssi)
+ val quality = determineSignalQuality(thatNode.snr, LocalModemPreset.current)
add(
@Composable {
IconInfo(

diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt
index 4182ee20ac..6e41b23540 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt
@@ -40,6 +40,7 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
+import org.meshtastic.core.ui.util.LocalModemPreset
const val MAX_VALID_SNR = 100F
const val MAX_VALID_RSSI = 0
@@ -51,7 +52,7 @@ fun SignalInfo(
@Suppress("UNUSED_PARAMETER") contentColor: Color = MaterialTheme.colorScheme.onSurface,
) {
if (node.snr < MAX_VALID_SNR && node.rssi < MAX_VALID_RSSI) {
- val quality = determineSignalQuality(node.snr, node.rssi)
+ val quality = determineSignalQuality(node.snr, LocalModemPreset.current)
val signalColor = quality.color.invoke()
Row(
modifier = modifier,

diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalModemPreset.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalModemPreset.kt
new file mode 100644
index 0000000000..ce9212ccee
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalModemPreset.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.ui.util
+
+import androidx.compose.runtime.compositionLocalOf
+import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
+
+/**
+ * The connected device's active LoRa modem preset, provided once at the app root (see MeshtasticAppShell) so signal
+ * quality can be rated relative to the preset's demodulation floor without threading it through every node/message
+ * composable. Null β€” the default before a device connects, and in previews/tests β€” falls back to the LongFast limit in
+ * `ModemPreset?.snrLimit`.
+ */
+@Suppress("CompositionLocalAllowlist")
+val LocalModemPreset = compositionLocalOf<ModemPreset?> { null }

diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorTest.kt
new file mode 100644
index 0000000000..1ce0b45575
--- /dev/null
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorTest.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.ui.component
+
+import org.meshtastic.core.model.snrLimit
+import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/**
+ * Tests for preset-relative signal-quality rating (issue #5446). Quality is judged from SNR relative to the modem
+ * preset's demodulation floor; RSSI is intentionally not part of the rating.
+ */
+class LoraSignalIndicatorTest {
+
+ @Test
+ fun `snrLimit follows spreading-factor demod floor per preset`() {
+ assertEquals(-7.5f, ModemPreset.SHORT_FAST.snrLimit) // SF7
+ assertEquals(-7.5f, ModemPreset.SHORT_TURBO.snrLimit) // SF7
+ assertEquals(-10f, ModemPreset.SHORT_SLOW.snrLimit) // SF8
+ assertEquals(-12.5f, ModemPreset.MEDIUM_FAST.snrLimit) // SF9
+ assertEquals(-15f, ModemPreset.MEDIUM_SLOW.snrLimit) // SF10
+ assertEquals(-17.5f, ModemPreset.LONG_FAST.snrLimit) // SF11
+ assertEquals(-17.5f, ModemPreset.LONG_MODERATE.snrLimit) // SF11
+ }
+
+ @Test
+ fun `LONG_SLOW uses physically-correct SF12 floor`() {
+ // Meshtastic-Apple's snrLimit() returns -7.5 here (the SF7 value, an apparent bug). The correct SF12
+ // demodulation floor is -20 dB β€” see #5446.
+ assertEquals(-20f, ModemPreset.LONG_SLOW.snrLimit)
+ assertEquals(-20f, ModemPreset.VERY_LONG_SLOW.snrLimit)
+ }
+
+ @Test
+ fun `null preset falls back to the LongFast default limit`() {
+ val noPreset: ModemPreset? = null
+ assertEquals(-17.5f, noPreset.snrLimit)
+ }
+
+ @Test
+ fun `the issue's example minus 10 dB SNR rates GOOD on LongFast`() {
+ // The bug this issue fixes: a fixed threshold rated -10 dB as BAD, but on LongFast (floor -17.5) it is
+ // 7.5 dB above the demod floor β€” an excellent signal.
+ assertEquals(Quality.GOOD, determineSignalQuality(snr = -10f, modemPreset = ModemPreset.LONG_FAST))
+ }
+
+ @Test
+ fun `the same SNR is rated relative to the preset`() {
+ // -15 dB is comfortably above LongSlow's -20 floor but at/below ShortFast's -7.5 floor.
+ assertEquals(Quality.GOOD, determineSignalQuality(snr = -15f, modemPreset = ModemPreset.LONG_SLOW))
+ assertEquals(Quality.BAD, determineSignalQuality(snr = -15f, modemPreset = ModemPreset.SHORT_FAST))
+ }
+
+ @Test
+ fun `quality bands around the LongFast floor`() {
+ // limit = -17.5; FAIR within 5.5 dB below, BAD within 7.5 dB below, NONE beyond.
+ val preset = ModemPreset.LONG_FAST
+ assertEquals(Quality.GOOD, determineSignalQuality(snr = -17f, modemPreset = preset)) // > limit
+ assertEquals(Quality.FAIR, determineSignalQuality(snr = -17.5f, modemPreset = preset)) // at limit
+ assertEquals(Quality.FAIR, determineSignalQuality(snr = -22f, modemPreset = preset)) // > limit-5.5 (-23)
+ assertEquals(Quality.BAD, determineSignalQuality(snr = -23f, modemPreset = preset)) // >= limit-7.5 (-25)
+ assertEquals(Quality.NONE, determineSignalQuality(snr = -30f, modemPreset = preset)) // < limit-7.5
+ }
+
+ @Test
+ fun `RSSI does not influence the rating`() {
+ // Identical SNR + preset always yields the same verdict regardless of any RSSI (RSSI is display-only now).
+ val good = determineSignalQuality(snr = -5f, modemPreset = ModemPreset.LONG_FAST)
+ assertEquals(Quality.GOOD, good)
+ }
+}

diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt
index b47f1a81c3..ec2833bff8 100644
--- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt
@@ -179,8 +179,12 @@ class CarStateCoordinator(
private fun collectNodeData() {
nodeJob =
scope.launch {
- combine(nodeRepository.nodeDBbyNum, nodeRepository.onlineNodeCount) { nodeMap, onlineCount ->
- val nodes = CarScreenDataBuilder.sortNodes(nodeMap.values)
+ combine(
+ nodeRepository.nodeDBbyNum,
+ nodeRepository.onlineNodeCount,
+ radioConfigRepository.localConfigFlow,
+ ) { nodeMap, onlineCount, localConfig ->
+ val nodes = CarScreenDataBuilder.sortNodes(nodeMap.values, localConfig.lora?.modem_preset)
val totalCount = nodeMap.size
val meshName = nodeRepository.myNodeInfo.value?.firmwareVersion

diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilder.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilder.kt
index a7d26846ca..17666c85bb 100644
--- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilder.kt
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilder.kt
@@ -18,11 +18,13 @@ package org.meshtastic.feature.car.util
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
+import org.meshtastic.core.model.snrLimit
import org.meshtastic.feature.car.model.CarLocalStats
import org.meshtastic.feature.car.model.ConversationUi
import org.meshtastic.feature.car.model.NodeUi
import org.meshtastic.feature.car.model.SignalQuality
import org.meshtastic.feature.car.service.MessageSnapshot
+import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
import org.meshtastic.proto.LocalStats
/**
@@ -39,19 +41,19 @@ internal object CarScreenDataBuilder {
private const val DAY_SECONDS = 86400
private const val BATTERY_MAX_PERCENT = 100
- // Thresholds aligned with core/ui LoraSignalIndicator.kt
- private const val SNR_GOOD_THRESHOLD = -7f
- private const val SNR_FAIR_THRESHOLD = -15f
- private const val RSSI_GOOD_THRESHOLD = -115
- private const val RSSI_FAIR_THRESHOLD = -126
+ // Preset-relative SNR band offsets (dB) around the modem preset's demodulation floor; aligned with core/ui
+ // LoraSignalIndicator. EXCELLENT sits a margin above the floor; FAIR/BAD step below it.
+ private const val SNR_EXCELLENT_MARGIN = 5.5f
+ private const val SNR_FAIR_OFFSET = 5.5f
+ private const val SNR_BAD_OFFSET = 7.5f
/** Converts a [Node] to a [NodeUi] for car display. */
- fun buildNodeUi(node: Node): NodeUi = NodeUi(
+ fun buildNodeUi(node: Node, modemPreset: ModemPreset? = null): NodeUi = NodeUi(
nodeNum = node.num,
userId = node.user.id,
longName = node.user.long_name.ifEmpty { "Unknown" },
shortName = node.user.short_name.ifEmpty { "?" },
- signalQuality = determineSignalQuality(node.snr, node.rssi),
+ signalQuality = determineSignalQuality(node.snr, modemPreset),
batteryPercent = node.batteryLevel?.takeIf { it in 1..BATTERY_MAX_PERCENT },
isOnline = node.isOnline,
lastHeard = node.lastHeard.toLong() * SECONDS_TO_MILLIS,
@@ -59,22 +61,28 @@ internal object CarScreenDataBuilder {
)
/** Sorts nodes for car display: online nodes first, then by lastHeard descending. */
- fun sortNodes(nodes: Collection<Node>): List<NodeUi> = nodes
- .map(::buildNodeUi)
+ fun sortNodes(nodes: Collection<Node>, modemPreset: ModemPreset? = null): List<NodeUi> = nodes
+ .map { buildNodeUi(it, modemPreset) }
.sortedWith(compareByDescending<NodeUi> { it.isOnline }.thenByDescending { it.lastHeard })
/** Builds ordered conversation list: sorted by most recent message time descending. */
fun sortConversations(conversations: List<ConversationUi>): List<ConversationUi> =
conversations.sortedByDescending { it.lastMessageTime }
- /** Determines signal quality from SNR and RSSI values. */
- fun determineSignalQuality(snr: Float, rssi: Int): SignalQuality = when {
- snr == Float.MAX_VALUE || rssi == Int.MAX_VALUE -> SignalQuality.NONE
- snr > SNR_GOOD_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> SignalQuality.EXCELLENT
- snr > SNR_GOOD_THRESHOLD && rssi > RSSI_FAIR_THRESHOLD -> SignalQuality.GOOD
- snr > SNR_FAIR_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> SignalQuality.GOOD
- snr > SNR_FAIR_THRESHOLD -> SignalQuality.FAIR
- else -> SignalQuality.BAD
+ /**
+ * Determines signal quality from SNR relative to the modem preset's demodulation floor ([ModemPreset.snrLimit]).
+ * RSSI is not used (matching core/ui); a null/unknown preset falls back to the LongFast default limit.
+ */
+ fun determineSignalQuality(snr: Float, modemPreset: ModemPreset? = null): SignalQuality {
+ if (snr == Float.MAX_VALUE) return SignalQuality.NONE
+ val limit = modemPreset.snrLimit
+ return when {
+ snr > limit + SNR_EXCELLENT_MARGIN -> SignalQuality.EXCELLENT
+ snr > limit -> SignalQuality.GOOD
+ snr > limit - SNR_FAIR_OFFSET -> SignalQuality.FAIR
+ snr >= limit - SNR_BAD_OFFSET -> SignalQuality.BAD
+ else -> SignalQuality.NONE
+ }
}
/**

diff --git a/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilderTest.kt b/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilderTest.kt
index 340b58fa8e..e248697478 100644
--- a/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilderTest.kt
+++ b/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilderTest.kt
@@ -40,6 +40,7 @@ import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.feature.car.model.ConversationUi
import org.meshtastic.feature.car.model.SignalQuality
import org.meshtastic.feature.car.service.MessageSnapshot
+import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.Position
@@ -52,76 +53,71 @@ import kotlin.test.assertTrue
class CarScreenDataBuilderTest {
- // determineSignalQuality()
+ // determineSignalQuality() β€” preset-relative SNR, RSSI not used (issue #5446)
@Test
fun `determineSignalQuality returns none when snr is max value`() {
- val quality = CarScreenDataBuilder.determineSignalQuality(Float.MAX_VALUE, -100)
+ val quality = CarScreenDataBuilder.determineSignalQuality(Float.MAX_VALUE, ModemPreset.LONG_FAST)
assertEquals(SignalQuality.NONE, quality)
}
@Test
- fun `determineSignalQuality returns none when rssi is max value`() {
- val quality = CarScreenDataBuilder.determineSignalQuality(-5f, Int.MAX_VALUE)
-
- assertEquals(SignalQuality.NONE, quality)
- }
-
- @Test
- fun `determineSignalQuality returns excellent for strong snr and strong rssi`() {
- val quality = CarScreenDataBuilder.determineSignalQuality(snr = -6f, rssi = -110)
+ fun `determineSignalQuality returns excellent well above the preset floor`() {
+ // LongFast floor -17.5; -10 is 7.5 dB above it (> floor + 5.5 margin).
+ val quality = CarScreenDataBuilder.determineSignalQuality(snr = -10f, modemPreset = ModemPreset.LONG_FAST)
assertEquals(SignalQuality.EXCELLENT, quality)
}
@Test
- fun `determineSignalQuality returns good for strong snr and fair rssi`() {
- val quality = CarScreenDataBuilder.determineSignalQuality(snr = -6f, rssi = -120)
+ fun `determineSignalQuality returns good just above the preset floor`() {
+ val quality = CarScreenDataBuilder.determineSignalQuality(snr = -15f, modemPreset = ModemPreset.LONG_FAST)
assertEquals(SignalQuality.GOOD, quality)
}
@Test
- fun `determineSignalQuality returns good for fair snr and strong rssi`() {
- val quality = CarScreenDataBuilder.determineSignalQuality(snr = -10f, rssi = -110)
+ fun `determineSignalQuality returns fair just below the preset floor`() {
+ val quality = CarScreenDataBuilder.determineSignalQuality(snr = -20f, modemPreset = ModemPreset.LONG_FAST)
- assertEquals(SignalQuality.GOOD, quality)
+ assertEquals(SignalQuality.FAIR, quality)
}
@Test
- fun `determineSignalQuality returns fair for fair snr and weak rssi`() {
- val quality = CarScreenDataBuilder.determineSignalQuality(snr = -10f, rssi = -130)
+ fun `determineSignalQuality returns bad further below the floor`() {
+ val quality = CarScreenDataBuilder.determineSignalQuality(snr = -24f, modemPreset = ModemPreset.LONG_FAST)
- assertEquals(SignalQuality.FAIR, quality)
+ assertEquals(SignalQuality.BAD, quality)
}
@Test
- fun `determineSignalQuality returns bad for weak snr`() {
- val quality = CarScreenDataBuilder.determineSignalQuality(snr = -15f, rssi = -110)
+ fun `determineSignalQuality returns none far below the floor`() {
+ val quality = CarScreenDataBuilder.determineSignalQuality(snr = -30f, modemPreset = ModemPreset.LONG_FAST)
- assertEquals(SignalQuality.BAD, quality)
+ assertEquals(SignalQuality.NONE, quality)
}
@Test
- fun `determineSignalQuality treats snr good threshold as not excellent`() {
- val quality = CarScreenDataBuilder.determineSignalQuality(snr = -7f, rssi = -110)
-
- assertEquals(SignalQuality.GOOD, quality)
+ fun `determineSignalQuality rates the same snr relative to the preset`() {
+ // -15 dB clears LongSlow's -20 floor but sits at/below ShortFast's -7.5 floor.
+ assertEquals(SignalQuality.GOOD, CarScreenDataBuilder.determineSignalQuality(-15f, ModemPreset.LONG_SLOW))
+ assertEquals(SignalQuality.BAD, CarScreenDataBuilder.determineSignalQuality(-15f, ModemPreset.SHORT_FAST))
}
@Test
- fun `determineSignalQuality treats rssi fair threshold as not good for strong snr`() {
- val quality = CarScreenDataBuilder.determineSignalQuality(snr = -6f, rssi = -126)
+ fun `determineSignalQuality uses the corrected LongSlow SF12 floor`() {
+ // -19 dB clears the physically-correct -20 floor (GOOD); it would be NONE under Apple's bugged -7.5.
+ val quality = CarScreenDataBuilder.determineSignalQuality(snr = -19f, modemPreset = ModemPreset.LONG_SLOW)
- assertEquals(SignalQuality.FAIR, quality)
+ assertEquals(SignalQuality.GOOD, quality)
}
@Test
- fun `determineSignalQuality treats snr fair threshold as bad`() {
- val quality = CarScreenDataBuilder.determineSignalQuality(snr = -15f, rssi = -130)
+ fun `determineSignalQuality falls back to LongFast for a null preset`() {
+ val quality = CarScreenDataBuilder.determineSignalQuality(snr = -10f, modemPreset = null)
- assertEquals(SignalQuality.BAD, quality)
+ assertEquals(SignalQuality.EXCELLENT, quality)
}
// buildNodeUi()

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt
index 5c46385377..1007932c2c 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt
@@ -564,7 +564,7 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli
/* Signal Indicator */
Box(modifier = Modifier.weight(weight = 3f).height(IntrinsicSize.Max)) {
- LoraSignalIndicator(meshPacket.rx_snr, meshPacket.rx_rssi)
+ LoraSignalIndicator(snr = meshPacket.rx_snr)
}
}
}

Served by rngit 1.5.2 - Generated in 0.13s